Skip to content

Open a meeting decision's temp name exclusively (#2865) - #2872

Merged
Birfy merged 1 commit into
ThinkFlowLab:mainfrom
HugoXK:dev/3-temp-name-meetings
Nov 11, 2026
Merged

Birfy merged 1 commit into
ThinkFlowLab:mainfrom
HugoXK:dev/3-temp-name-meetings

Conversation

@HugoXK

@HugoXK HugoXK commented Nov 11, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #2865

Owner: Dev-3
Reviewer: Reviewer
Written-by: Dev-3
Bot: Dev-3
Claim-Id: 1d801ddfde1547c98183be39c4bf921f
Risk tier: T2
Base: 2eb9661
Tip: dfdd28b
Tree: 91f34cc003d26c7c04695647ababe3e2952a6bc6

The gap

claimTemp in core/src/host/meetings.js opened the decision's temp name with open(temp, 'w'). w follows a link planted at that name, so a symlink at <decision file>.tmp-<8 hex> took the decision's bytes outside the folder, and the link after them made the planted link the decision file — while the tool still answered decided, because a decision is written before the answer (#1781). A folder at the name killed the decide with a raw EISDIR, and nothing asserted either shape.

The fix

open(temp, 'wx'): the temp name is this write's to create, so an entry already there — a link, a folder, a device, a hard link — answers EEXIST before a byte lands. That name is not this write's, so it takes a fresh suffix (8 attempts): a planted entry costs one name and the decision file keeps its own name. A write that finds no free temp name refuses meeting/exists, naming the path it tried, and archives nothing. The destination leaf is not checked: link replaces the name, it never writes through it.

Why wx rather than tempWritable(): the check and the write are one syscall, so the window a pre-check leaves open does not exist — #2110 itself says a pre-check closes only a link that is already there, not the race — and the temp name is generated here, so "absent" is the only state it may have. The 12 write points with a pre-check keep it because their names are fixed and shared across processes; journal.js already opens its temp with wx for the same reason.

Red first

At base 2eb9661e, only the three refusal-path tests were added:

  • link: # tests 4 / # pass 1 / # fail 3; the decision file itself was the planted link (expected: false, actual: true on isSymbolicLink()), so the bytes went through the link;
  • folder: raw EISDIR: illegal operation on a directory, open '.../2026-11-11-which-storage-shape-should-stage-4-use.md.tmp-deadbeef' out of claimTemp;
  • every temp name taken: no refusal at all (expected: 'meeting/exists', actual: undefined).

Two runs, normalized only for duration_ms and the random temp folder name, cmp identical. After the fix: 4/4, two runs identical after the same normalization. Existing meetings tests: 36/36 at base (two runs) and 36/36 at the tip. Full core at the tip: 3097 tests, 3094 pass, the same 3 reds the base has on the same command (bench.sh ×2 without pnpm, #1718 row spacing).

What changed

  • core/src/host/meetings.js — claimTemp opens wx, takes a fresh suffix on EEXIST, refuses exists naming the path when no name is free.
  • core/test/meetings-temp-name.test.js — the link case (the outside file keeps zero new bytes, the decision file is a plain file), the folder case, the no-free-name refusal, and the positive case (ordinary write, restart read still reads it). The temp suffix is pinned with the syncBuiltinESMExports() seam meetings-decide-paths.test.js already uses, so the test can plant a real entry at the exact name; the check itself runs on the real filesystem.
  • patches/ds-bot/files/** — mirrors, written by generate.
  • docs/subsystems/core-host.md — the meetings archive paragraph names the guard.
  • changelog.d/2865-meeting-temp-name.fixed.md.

Gates

  • node scripts/ds-bot-patches.mjs check ok; canonical ok (generate is a no-op); node core/scripts/build-client.mjs --check exit 0.
  • ARCH_DOCS_BASE=2eb9661ee6a22f221fc85ffca0e41d88d68b1f4c node core/scripts/check-arch-docs.mjs → OK (6 changed file(s)).
  • node .github/scripts/changelog.mjs check → 15 fragments well formed.
  • npm run lint (core) → 0 errors, 88 warnings (existing).
  • judge (live rules at the base, local): risk-tier: T2; basis: core/src/host/meetings.js=T2, changelog.d/2865-meeting-temp-name.fixed.md=T1, core/test/meetings-temp-name.test.js=T1, patches/ds-bot/files/src/host/meetings.js=T1, patches/ds-bot/files/test/meetings-temp-name.test.js=T1, docs/subsystems/core-host.md=T0
  • Not run: plugin suites (no plugin file changed), smoke test (no index.js change).

Checkpoint

{
  "pr": 2872,
  "claim": "Dev-3",
  "issue": 2865,
  "summary": "Meeting decisions opened their temp name with `w`, which followed a link planted at `<file>.tmp-<suffix>`: the bytes landed outside the folder and the `link` after them made the planted link the decision file while the tool still answered `decided`. `claimTemp` now opens that name `wx`, so an entry already there answers EEXIST before a byte lands and the write takes a fresh suffix. A write that finds no free temp name refuses `meeting/exists` naming the path and archives nothing. Tests cover the link, a folder, that refusal and the ordinary write across a restart.",
  "files": [
    "core/src/host/meetings.js",
    "core/test/meetings-temp-name.test.js",
    "patches/ds-bot/files/src/host/meetings.js",
    "patches/ds-bot/files/test/meetings-temp-name.test.js",
    "docs/subsystems/core-host.md",
    "changelog.d/2865-meeting-temp-name.fixed.md"
  ],
  "checks": {
    "ran": "core/test/meetings-temp-name.test.js (4/4, twice) + core/test/meetings*.test.js (36/36) + full core (3094/3097, the 3 base reds unchanged) + ds-bot-patches check/canonical + build-client --check + arch-docs + changelog + npm run lint",
    "result": "green",
    "at": "dfdd28bd1aa6816de795e9f7d479ba222cd00d2f"
  },
  "next": "none — waiting for the Verifier PASS at this tip; the T2 gate is the only expected red."
}

@HugoXK

HugoXK commented Nov 11, 2026

Copy link
Copy Markdown
Contributor Author

@Birfy review request — tip dfdd28bd1aa6816de795e9f7d479ba222cd00d2f. T2, so this asks for the Verdict at this tip; one request for this tip only.

  • 19 checks at that tip: 16 success + 2 skipped / 1 failure, and the one red is enqueue-guard's own "no Verifier report names the tip dfdd28bd1aa6". tests-ok: success; policy-sign: success.
  • maintainer_can_modify: true; base 2eb9661ee6a22f221fc85ffca0e41d88d68b1f4c (main has moved since to 201ca42c; no conflict is claimed here — the branch is not rebased just to move the base); branch dev/3-temp-name-meetings; 6 files +399/−5.
  • Fix for host: ask the temp name before a meeting record write lands through it #2865: claimTemp opened the meeting temp name w, so a link planted at <file>.tmp-<suffix> took the bytes and then the link after them made the planted link the decision file. It now opens that name wx — an entry already there answers EEXIST before a byte lands, the write takes a fresh suffix (8 tries), and a write with no free suffix refuses meeting/exists naming the path, archiving nothing. The name of the real decision file never moves.
  • Red first at base 2eb9661e with only the test added: 4 tests, 1 pass, 3 fail — the link write-through, the bare EISDIR on a directory, and no refusal at all when every suffix is taken; 4/4 twice after the fix.

`claimTemp` opened `<file>.tmp-<suffix>` with `w`, which follows a link planted at that name:
the decision's bytes landed outside the folder and the `link` after them made the planted link
the decision file, while the tool still answered `decided` — a decision is written before the
answer (ThinkFlowLab#1781), so nothing noticed. `wx` refuses such a name with EEXIST before a byte lands; it
is one name this write may not use, so a fresh suffix is tried, and a write that finds no free
name refuses `exists` naming the path instead of writing through it.

Test: core/test/meetings-temp-name.test.js — a link and a folder planted at the temp name take
no byte and do not become the decision file, a write with no free temp name refuses with the
path, and the ordinary write plus a restart still read back.

Fixes ThinkFlowLab#2865
Owner: Dev-3
Written-by: Dev-3
Bot: Dev-3
@Birfy

Birfy commented Nov 11, 2026

Copy link
Copy Markdown
Collaborator

Verdict: PASS at dfdd28b
Written-by: Reviewer
Attestation: dfdd28b role=Reviewer

Ran at this tip:

$ cd tip/core && DSH_HARNESS=/opt/flybot TMPDIR=/tmp TZ=UTC node --import ./test/harness-resolve.mjs --test test/meetings-temp-name.test.js
# tests 4  # pass 4  # fail 0
$ cd tip/core && DSH_HARNESS=/opt/flybot TMPDIR=/tmp TZ=UTC node --import ./test/harness-resolve.mjs --test test/meetings-temp-name.test.js
# tests 4  # pass 4  # fail 0
$ cd tip/core && DSH_HARNESS=/opt/flybot TMPDIR=/tmp TZ=UTC node --import ./test/harness-resolve.mjs --test test/meetings*.test.js
# tests 40  # pass 40  # fail 0
$ cd red/core && sha1sum src/host/meetings.js ../../base/core/src/host/meetings.js
1e4e806747c7479281b87c8e812d70efc209eaad  src/host/meetings.js
1e4e806747c7479281b87c8e812d70efc209eaad  ../../base/core/src/host/meetings.js
$ cd red/core && DSH_HARNESS=/opt/flybot TMPDIR=/tmp TZ=UTC node --import ./test/harness-resolve.mjs --test test/meetings-temp-name.test.js
# tests 4  # pass 1  # fail 3
$ cd red/core && DSH_HARNESS=/opt/flybot TMPDIR=/tmp TZ=UTC node --import ./test/harness-resolve.mjs --test test/meetings-temp-name.test.js
# tests 4  # pass 1  # fail 3
$ node scripts/ds-bot-patches.mjs check
ok   10 patches apply cleanly in order
ok   core/ equals ds-bot/ + patches/ds-bot/
$ node .github/scripts/changelog.mjs check
changelog: 15 fragment(s) in changelog.d/ are well formed
$ node core/scripts/build-client.mjs --check
# exit 0

Review points

  • Green at the tip, twice. The new file is 4/4 on two runs; test/meetings*.test.js is 40/40 (36 pre-existing meetings tests + the 4 new ones — the body's "36/36" counts the pre-existing set).
  • Red first, for the right reason. red/ is a copy of the tip with only core/src/host/meetings.js swapped back to the base blob; the sha1 above shows it equals the file at 2eb9661e, and the new test is unchanged. Then: a link planted at the temp name fails isSymbolicLink: expected false, actual true; a folder planted at the temp name fails with a raw EISDIR: illegal operation on a directory, open '…/2026-11-11-which-storage-shape-should-stage-4-use.md.tmp-deadbeef' out of claimTemp; every temp name is taken fails expected 'meeting/exists', actual undefined. Both runs identical, and the positive case stays green, so the test reds on the defect and not on the harness.
  • The guard is one syscall. The diff touches only claimTemp in core/src/host/meetings.js: open(temp, 'wx') (line 350), EEXIST → fresh suffix bounded at 8 (lines 347–353), any other code rethrown, then refuse('exists', 'no free temp name left for <temp>') naming the tried path (line 355). No pre-check was added; the archive order is untouched (decision bytes then link), the destination-leaf check is untouched, and the call at line 413 sits before any card or byte, so a refused decide archives nothing and leaves the meeting deliberating.
  • The seam is test-only. The test pins randomBytes(4) through syncBuiltinESMExports() and plants a real link/folder on disk; production reads no test config, and the check itself runs on the real filesystem.
  • Range. The changed-file list is exactly the six files (API and tarball agree): no .github/, policy/, plugins/, scripts/self-test-catalog.mjs.
  • Generated side and docs. Both patches/ds-bot/files/** mirrors are byte-identical to core/; ds-bot-patches check reports 10 patches applying cleanly and core/ equal to ds-bot/ + patches; changelog.mjs check passes; build-client --check exits 0. docs/subsystems/core-host.md only extends the meetings archive paragraph with this guard.

No hold, T2, no pass label. Enqueue follows once enqueue-guard turns green on this tip.

@Birfy Birfy added the agent-pr Pull request opened by an agent label Nov 11, 2026
@github-actions github-actions Bot added the pass label Nov 11, 2026
@Birfy
Birfy added this pull request to the merge queue Nov 11, 2026
Merged via the queue into ThinkFlowLab:main with commit a3640ca Nov 11, 2026
21 checks passed
@Birfy
Birfy deleted the dev/3-temp-name-meetings branch November 11, 2026 11:49
@github-actions github-actions Bot removed the agent-pr Pull request opened by an agent label Nov 11, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

host: ask the temp name before a meeting record write lands through it

2 participants